Components: product

These are the RenewCMS components, built from the primitives on the first page. Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent.

AuthShell

One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task.

Log inWelcome back. Log in to manage your articles.
default slot
Forgot password?System
Built fromCustom layout component AuthShell.vue: a UPageCard variant="outline" inside a centered main, with AppLogo, and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm, because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose. Props, slotsProps title: string, description?: string. Slots default, notice (an alert above the title, for session expired or password changed), footer (links). No emits. A11yThe title is the page's h1, and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1. The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to. Responsive400 px card from sm up, with its top 12 vh from the top. Below sm, the card loses its border and padding drops to 20 px, filling the screen on --ui-bg, and fields and buttons use xl.

DashboardShell

The sidebar has three destinations, and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg, the sidebar becomes a slideover.

RenewCMS
Home
Articles
Integration
martaOwner
System
Light
Dark
Log out
MAmarta
ArticlesHow we price a bathroom renovation
page body
MA
Articles
Mobile top bar. The menu button opens the sidebar as a left slideover.
Built fromUDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default (UNavigationMenu orientation="vertical" :collapsed) and #footer (UserMenu) slots. Each page is a UDashboardPanel, with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb. On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over. UserMenuUDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. Props, slotsNo props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb. Slots navbar-right (page actions on mobile) and default. It shows the offline banner when navigator.onLine is false. A11yThe first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page". In the collapsed rail, items show tooltips and keep their aria-label. The collapse button carries aria-expanded. The mobile slideover traps focus and closes after navigating. BendThe active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts.

PageHeader

ArticlesEverything you've written, online and in draft.
Connect your websiteNew article
ArticlesEverything you've written, online and in draft.
New article
Built fromUPageHeader :title :description, with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' }, which brings Nuxt UI's marketing-size header down to dashboard density. Props, slotstitle, description?, slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor). ResponsiveBelow md, the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu.

ArticleStatusBadge

Online Draft Online Draft Publishing… Unpublishing…
Built fromUBadge. Online: color="neutral" variant="solid" icon="i-lucide-globe". Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line". Status is shown by the fill, the icon and the word, never by color alone. Propsonline: boolean, size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive.

ArticleTable

Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md, the table becomes a card list with the same data and actions.

All6Online4Drafts2
Search by title
TitleStatusCreatedUpdated
{{ a.t }} OnlineDraft {{ a.c }}{{ a.u }}
{{ a.t }}
OnlineDraftUpdated {{ a.u }}
Built fromUTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md. Each row has a UDropdownMenu, and the row actions go through useArticleActions(), which shares one set of calls between the table, the list and the editor. Props, emitsProps articles: Article[], loading: boolean, busyIds: Set<number>. Emits publish(id), unpublish(id), delete(article), copy-url(id). Search, tab and sort state lives in the URL query (?q=&status=&sort=). Slots #empty, #toolbar-right. BehaviorSearch is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu. StatesLoading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives. A11yThe table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles".

EmptyState

No articles yetWrite your first one. It stays a draft until you publish it.New article
No articles match "tiles"Check the spelling, or search for a shorter part of the title.Clear search
No draftsEverything you've written is online.New article
Nothing is online yetPublish a draft and it will appear on your website.Show drafts
Built fromUEmpty :icon :title :description :actions variant="naked", placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated. Propsvariant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online', query?: string. Emits action. Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters. A11yThe title is an h2. "No results" is announced through the table's live region. Clear search returns focus to the search field.

ConfirmDialog

Delete this article?"Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
CancelDelete article
Delete this article?"Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
Couldn't delete the article, so it's still there. Try again in a moment.
CancelDeleting…

Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading).

Built fromUModal opened through useOverlay().create(ConfirmDialog). open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay. Props, emitstitle, description, confirmLabel, pendingLabel, tone: 'danger' | 'neutral', action: () => Promise<void>. Emits close(confirmed: boolean). The delete description comes from the title plus images.length, and reads "and its image" for 1 and just the title for 0. Other usesCancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone. A11yrole="alertdialog". Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one.

ArticleEditor, SaveStatus, PublishControl, CharacterCounter

The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot.

How we price a bathroom renovationDraft
Unsaved changes Save Save and publish Delete
{{ t.text }}

Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays.

What goes into the price
  • Stripping out and making good
  • Plumbing moves, if any
Details
Created12 Mar 2026, 09:14UpdatedToday, 11:02ID12Images3
Public API URL/v1/article/12Returns 404 until the article is online.
4 318 / 10 000
Unsaved changesdirty, Save enabled
Saving…saving, Save disabled
Saved at 11:02saved, Save disabled
Save failedRetryerror, changes kept
Publishdraft, saved
Unpublishonline
9 240 / 10 000body ≥ 90%
10 214 / 10 000214 characters over. Shorten the text or remove an image. Formatting counts too.over limit, Save disabled
ArticleEditorUEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed". TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 (h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem/liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4", because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes(). The editor surface gets class="article-content". Props, emitsmodelValue: string (HTML), articleId: number, disabled?. Emits update:modelValue, request-image. Exposes insertImage(src, alt) and focus(). The toolbar reflects the active state (bold is on at the cursor) using aria-pressed. SaveStatusA custom inline span aria-live="polite", driven by useArticleDraft(): 'clean' | 'dirty' | 'saving' | 'saved' | 'error'. Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline". Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload. There is no autosave, so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry. PublishControlA single UButton. Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online.; "Article unpublished", It's a draft again and hidden from your website. CharacterCounterA custom component in mono type. Props value: number, max: number, min?: number, warnAt?: number (default 0.9). The title uses [...str].length, so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke. ResponsiveBelow lg, the details panel becomes a right slideover, opened with i-lucide-panel-right. Below md, the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start.

ImageUploader

Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes.

Drop an image hereChoose filePNG, JPEG, WEBP, SVG · max 10 MB
idle
Drop to upload
dragging over
north-wall.jpg62%
Uploading 2.4 of 3.9 MB
uploading
Image added
success, closes after 600 ms
"plan.pdf" can't be usedUse a PNG, JPEG, WEBP or SVG image, up to 10 MB.Choose another file
rejected: type or size
Upload failedThe server didn't accept the file. Your article hasn't changed.Try again
server error
Built fromUModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs". Uploads go through XMLHttpRequest, because fetch has no upload progress, as FormData with the field name file. The 201 location is inserted with insertImage(location, ''). ValidationBefore uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024. The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection. Props, emitsarticleId: number. Emits uploaded(location), close. A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles. A11y, touchThe drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow, and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file.

CodeBlock and EndpointCard

GET/v1/article/{id}
Returns one article. Drafts return 404 to anonymous callers, the same as an id that doesn't exist.
fetchcurlResponse
JavaScriptCopied
const res = await fetch('https://cms.renew.example/v1/article/12')
if (res.status === 404) return notFound()
const article = await res.json()
CodeBlockThis is a custom component, because Nuxt UI's prose code blocks rely on Nuxt Content and don't work in a plain Vue app. Highlighting is done ahead of time with shiki (a dual github-light/github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton, which switches to "Copied" for 2 s through useClipboard. Props code, lang, label?. Long lines scroll horizontally and never wrap. EndpointCardUCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method, path, description, examples: { fetch, curl, response }. Snippets use the current location.origin. A11yThe copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre (tabindex="0") so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes.

PasswordStrengthHint

At least 8 charactersnot met yet
At least 8 charactersmet
At least 8 charactersnot met on submit
Passwords matchreset screen only
Built fromA custom ul in the password UFormField's #help slot. Props rules: { label: string, met: boolean }[], showErrors: boolean. There is no meter or score, because the API only requires 8 characters. A11yThe list is the field's aria-describedby. Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met.

ResultPanel

Check your emailIf an account exists for that email, a password reset link has been sent. The link expires in 1 hour.
Back to log in
This link has expiredReset links work once, for 1 hour. Request a new one and use it within the hour.
Request a new link
Too many attemptsFor your security, wait a moment before trying again. You can try again in 0:42.
Try again in 0:42API pending
Built fromA custom component that fills AuthShell's slot: an icon tile, h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info', icon, title, description, actions: ButtonProps[]. Slot default for extras such as the inline login on verify. A11yWhen the panel replaces a form, focus moves to its title and the panel is role="status", or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero.

Error pages and offline banner

You're offline. You can keep writing, but saving and publishing will wait until you're back online.
404Page not foundThe link may be mistyped, or the page may have moved.
Go to HomeAll articles
ErrorSomething went wrongThis page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.
Try againGo to Home
Built fromUError isn't used because it's built for Nuxt's error.vue. Instead there's a custom ErrorView (props kind: 'not-found' | 'error'), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner, shown and hidden with useOnline(). Once the connection returns it switches to "You're back online" for 3 s. A11yThe error title is the page h1 and gets focus on render. The banner is role="status", and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip.